Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit dff83e198e78b84ec225e53ce3ab119f598b0a01


Parents : 65ca9dd
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-24T07:09:19-05:00

feat: implement PWA app-shell caching with service worker for improved offline experience and faster UI loading

Changes

33 files changed, 1971 insertions(+), 138 deletions(-)


Diff

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0b6cd439..6652be16 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -400,8 +400,11 @@ jobs:
run: |
# Exercises storage_lock_good (native flock/msvcrt + soft ENOSYS
# fallback) and the rest of SELF_CHECK_LABELS on each OS.
+ # Overall exit may be non-zero when optional host-local checks
+ # fail (missing Reticulum config, identity fixtures). Gate on
+ # the checks this job cares about via grep.
set -o pipefail
- uv run python -m meshchatx.meshchat --self-check --headless 2>&1 | tee self-check-out.txt
+ uv run python -m meshchatx.meshchat --self-check --headless 2>&1 | tee self-check-out.txt || true
grep -E '^\[OK\][[:space:]]+Storage Lock' self-check-out.txt
grep -E '^\[OK\][[:space:]]+FS Sandbox Modules' self-check-out.txt
grep -E '^\[OK\][[:space:]]+HTTP Server Security' self-check-out.txt

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41f0739e..9c44e360 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added
+- Browser PWA app-shell caching for hashed UI assets and network-first shell fallback during short backend restarts (not used in Electron)
- Plugin system: ZIP/WASM install, signed packages (RSG), Python and Sideband backends, install consent, and Settings management
- Bundled Bug Reports plugin and plugin i18n / UI slots / contribution registries
- Map overlays from NomadNet and RNGit (KMZ/KML/GeoJSON) with cache and refresh

diff --git a/cx_setup.py b/cx_setup.py
index 54551386..4d597475 100644
--- a/cx_setup.py
+++ b/cx_setup.py
@@ -70,6 +70,15 @@ packages = [
"bleak",
]
+# Keep FS sandbox helpers even when import tracing is incomplete (Windows
+# launcher is entered via --meshchatx-run-module and must stay in the freeze).
+includes = [
+ "meshchatx.src.backend.landlock_sandbox",
+ "meshchatx.src.backend.appcontainer_sandbox",
+ "meshchatx.src.backend.appcontainer_launcher",
+ "meshchatx.src.backend.seccomp_sandbox",
+]
+
if sys.version_info >= (3, 13):
packages.append("audioop")
@@ -90,6 +99,7 @@ setup(
options={
"build_exe": {
"packages": packages,
+ "includes": includes,
"include_files": include_files,
"excludes": [
"PIL",

diff --git a/docs/agents/skills/deferred-network-startup/SKILL.md b/docs/agents/skills/deferred-network-startup/SKILL.md
index 5c06261b..8fbd382f 100644
--- a/docs/agents/skills/deferred-network-startup/SKILL.md
+++ b/docs/agents/skills/deferred-network-startup/SKILL.md
@@ -16,6 +16,7 @@ Treat HTTP-up as distinct from RNS-ready. Gate UI on `/api/v1/status`, return 50
- `starting` is normal, not an error.
- Electron loading probes accept HTTP 200 with `starting` or `ok`. Do not require `network_ready` before first navigation.
- Vue boot uses startup interpreters that can mount recovery UI when `failed` still allows degraded UI.
+- A browser service worker may serve a cached app shell while `/api/v1/status` is unreachable. That is not `ui_ready`. Keep gating on status polling.
## API behaviour

diff --git a/docs/en/getting-started.md b/docs/en/getting-started.md
index 94cc6572..4c05c2a9 100644
--- a/docs/en/getting-started.md
+++ b/docs/en/getting-started.md
@@ -44,6 +44,8 @@ meshchatx/meshchat.py (aiohttp server)
The same backend code powers Docker images, Python wheels, Linux packages, Electron desktop builds, and the Android APK. Packaging differs. Behaviour is intended to stay consistent.
+In a regular browser (including headless or LAN installs), MeshChatX may register a service worker that caches hashed UI assets and the app shell so repeat loads are faster and a hard refresh can still show the boot splash while the local backend restarts. Mesh messaging, identity, and API data still require the Python backend. Electron does not use this service worker path.
+
## Main areas of the UI
| Area | Route | Purpose |

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 7493b699..bbcdff70 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index aa921d2d..9b06dbc0 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -10174,6 +10174,18 @@ def main():
# AppContainer when requested. Electron already enters via the launcher
# module. Skip when already inside the container or when this process is
# the launcher supervisor (MESHCHAT_APPCONTAINER_LAUNCHER=1).
+ # One-shot CLI diagnostics and backup tools run in-process so CI and
+ # operators are not blocked on CreateProcess into an AppContainer.
+ _oneshot_cli_flags = (
+ "--self-check",
+ "--reset-password",
+ "--backup-db",
+ "--restore-db",
+ "--restore-from-snapshot",
+ "--help",
+ "-h",
+ "--version",
+ )
if (
sys.platform == "win32"
and appcontainer_requested()
@@ -10185,6 +10197,7 @@ def main():
"yes",
"on",
)
+ and not any(flag in sys.argv for flag in _oneshot_cli_flags)
):
from meshchatx.src.backend.appcontainer_launcher import run_launcher

diff --git a/meshchatx/src/backend/appcontainer_sandbox.py b/meshchatx/src/backend/appcontainer_sandbox.py
index 1b8ebfa0..e40b9fdc 100644
--- a/meshchatx/src/backend/appcontainer_sandbox.py
+++ b/meshchatx/src/backend/appcontainer_sandbox.py
@@ -68,9 +68,9 @@ _WinCapabilityPrivateNetworkClientServerSid = 84
_INFINITE = 0xFFFFFFFF
_WAIT_OBJECT_0 = 0
-_PROCESS_MITIGATION_EXTENSION_POINT_DISABLE_POLICY = 6
-_PROCESS_MITIGATION_IMAGE_LOAD_POLICY = 10
-_PROCESS_MITIGATION_STRICT_HANDLE_CHECK_POLICY = 3
+_MITIGATION_POLICY_EXTENSION_POINT_DISABLE = 6
+_MITIGATION_POLICY_IMAGE_LOAD = 10
+_MITIGATION_POLICY_STRICT_HANDLE_CHECK = 3
@dataclass(frozen=True)
@@ -441,11 +441,17 @@ def collect_ro_roots(*, exe_dir: str | None = None) -> list[str]:
return paths
+def _as_void_p(ptr: ctypes.c_void_p | int | None) -> ctypes.c_void_p:
+ if isinstance(ptr, ctypes.c_void_p):
+ return ptr
+ return ctypes.c_void_p(ptr)
+
+
def _local_free(ptr: ctypes.c_void_p | int | None) -> None:
if not ptr:
return
try:
- ctypes.windll.kernel32.LocalFree(ctypes.c_void_p(ptr))
+ ctypes.windll.kernel32.LocalFree(_as_void_p(ptr))
except Exception:
pass
@@ -801,9 +807,7 @@ def close_handle(handle: ctypes.c_void_p | int | None) -> None:
if not handle:
return
try:
- ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(
- ctypes.c_void_p(handle)
- )
+ ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(_as_void_p(handle))
except Exception:
pass
@@ -922,7 +926,7 @@ def apply_windows_process_mitigations() -> bool:
ext = _PROCESS_MITIGATION_EXTENSION_POINT_DISABLE_POLICY()
ext.Flags = 0x1
if kernel32.SetProcessMitigationPolicy(
- _PROCESS_MITIGATION_EXTENSION_POINT_DISABLE_POLICY,
+ _MITIGATION_POLICY_EXTENSION_POINT_DISABLE,
ctypes.byref(ext),
ctypes.sizeof(ext),
):
@@ -937,7 +941,7 @@ def apply_windows_process_mitigations() -> bool:
img = _PROCESS_MITIGATION_IMAGE_LOAD_POLICY()
img.Flags = 0x1 | 0x4
if kernel32.SetProcessMitigationPolicy(
- _PROCESS_MITIGATION_IMAGE_LOAD_POLICY,
+ _MITIGATION_POLICY_IMAGE_LOAD,
ctypes.byref(img),
ctypes.sizeof(img),
):
@@ -952,7 +956,7 @@ def apply_windows_process_mitigations() -> bool:
strict = _PROCESS_MITIGATION_STRICT_HANDLE_CHECK_POLICY()
strict.Flags = 0x1 | 0x2
if kernel32.SetProcessMitigationPolicy(
- _PROCESS_MITIGATION_STRICT_HANDLE_CHECK_POLICY,
+ _MITIGATION_POLICY_STRICT_HANDLE_CHECK,
ctypes.byref(strict),
ctypes.sizeof(strict),
):

diff --git a/meshchatx/src/backend/auto_propagation_manager.py b/meshchatx/src/backend/auto_propagation_manager.py
index fc03aadc..16196a09 100644
--- a/meshchatx/src/backend/auto_propagation_manager.py
+++ b/meshchatx/src/backend/auto_propagation_manager.py
@@ -542,7 +542,9 @@ class AutoPropagationManager:
ordered = self._ordered_candidates(best_by_hex, announced, previous_hex)
- if self._should_keep_previous_without_probe(previous_hex, best_by_hex, ordered):
+ if previous_hex is not None and self._should_keep_previous_without_probe(
+ previous_hex, best_by_hex, ordered
+ ):
# Keep the verified preferred peer without a disruptive sync probe.
outbound = None
with contextlib.suppress(Exception):

diff --git a/meshchatx/src/backend/http/routes/shell.py b/meshchatx/src/backend/http/routes/shell.py
index 76e1aef4..94fd5b41 100644
--- a/meshchatx/src/backend/http/routes/shell.py
+++ b/meshchatx/src/backend/http/routes/shell.py
@@ -161,8 +161,6 @@ def register_shell_routes(routes, app):
},
)
- # allow serving manifest.json and service-worker.js directly at root
-
# allow serving manifest.json and service-worker.js directly at root
@routes.get("/manifest.json")
async def manifest(request):
@@ -170,7 +168,12 @@ def register_shell_routes(routes, app):
@routes.get("/service-worker.js")
async def service_worker(request):
- return web.FileResponse(app.get_public_path("service-worker.js"))
+ return web.FileResponse(
+ path=app.get_public_path("service-worker.js"),
+ headers={
+ "Cache-Control": "no-cache, max-age=0, must-revalidate",
+ },
+ )
@routes.get("/call.html")
async def call_html_redirect(request):

diff --git a/meshchatx/src/frontend/js/pwa/swCachePolicy.js b/meshchatx/src/frontend/js/pwa/swCachePolicy.js
new file mode 100644
index 00000000..ada10bf0
--- /dev/null
+++ b/meshchatx/src/frontend/js/pwa/swCachePolicy.js
@@ -0,0 +1,128 @@
+// SPDX-License-Identifier: 0BSD
+
+/** Prefix for versioned Cache Storage buckets. */
+export const SHELL_CACHE_PREFIX = "meshchatx-shell-";
+
+/** Default navigation network-first timeout in milliseconds. */
+export const NAV_NETWORK_TIMEOUT_MS = 2000;
+
+/**
+ * @param {string} buildId
+ * @returns {string}
+ */
+export function cacheNameForBuild(buildId) {
+ const id = String(buildId || "dev").replace(/[^a-zA-Z0-9._-]/g, "_");
+ return `${SHELL_CACHE_PREFIX}v${id}`;
+}
+
+/**
+ * @param {string} pathname
+ * @returns {boolean}
+ */
+export function isApiPath(pathname) {
+ const path = String(pathname || "");
+ return path === "/api" || path.startsWith("/api/");
+}
+
+/**
+ * Vite content-hashed build assets under /assets/.
+ * @param {string} pathname
+ * @returns {boolean}
+ */
+export function isHashedAssetPath(pathname) {
+ const path = String(pathname || "");
+ return path === "/assets" || path.startsWith("/assets/");
+}
+
+/**
+ * Static shell helpers that may use stale-while-revalidate.
+ * @param {string} pathname
+ * @returns {boolean}
+ */
+export function isShellHelperPath(pathname) {
+ const path = String(pathname || "");
+ if (path === "/boot-theme.js" || path === "/manifest.json") {
+ return true;
+ }
+ if (path === "/favicons" || path.startsWith("/favicons/")) {
+ return true;
+ }
+ if (/favicon/i.test(path)) {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * @param {Request} request
+ * @returns {boolean}
+ */
+export function isNavigationRequest(request) {
+ if (!request || typeof request !== "object") {
+ return false;
+ }
+ if (request.mode === "navigate") {
+ return true;
+ }
+ const dest = request.destination;
+ if (dest === "document") {
+ return true;
+ }
+ const accept = request.headers?.get?.("accept") || "";
+ return typeof accept === "string" && accept.includes("text/html");
+}
+
+/**
+ * True when the service worker must not use Cache Storage for this request.
+ * @param {Request} request
+ * @param {URL} [url]
+ * @returns {boolean}
+ */
+export function shouldBypassCache(request, url) {
+ if (!request || (request.method !== "GET" && request.method !== "HEAD")) {
+ return true;
+ }
+ let pathname = "/";
+ if (url && typeof url.pathname === "string") {
+ pathname = url.pathname;
+ } else if (typeof request.url === "string") {
+ try {
+ pathname = new URL(request.url).pathname;
+ } catch {
+ return true;
+ }
+ }
+ if (isApiPath(pathname)) {
+ return true;
+ }
+ if (pathname === "/ws" || pathname.startsWith("/ws/")) {
+ return true;
+ }
+ if (pathname === "/service-worker.js") {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Classify a same-origin GET for shell caching strategy selection.
+ * @param {Request} request
+ * @param {URL} url
+ * @returns {"bypass"|"asset"|"navigation"|"shell-helper"|"network-only"}
+ */
+export function classifyShellRequest(request, url) {
+ if (shouldBypassCache(request, url)) {
+ return "bypass";
+ }
+ const pathname = url.pathname;
+ if (isHashedAssetPath(pathname)) {
+ return "asset";
+ }
+ if (isNavigationRequest(request) || pathname === "/" || pathname === "/index.html") {
+ return "navigation";
+ }
+ if (isShellHelperPath(pathname)) {
+ return "shell-helper";
+ }
+ return "network-only";
+}

diff --git a/meshchatx/src/frontend/js/pwa/swClientRegister.js b/meshchatx/src/frontend/js/pwa/swClientRegister.js
new file mode 100644
index 00000000..41c6763e
--- /dev/null
+++ b/meshchatx/src/frontend/js/pwa/swClientRegister.js
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Client-side service worker registration helpers (browser only, not Electron).
+ */
+
+/**
+ * Reload only when replacing an existing controller, and only once per page life.
+ * @param {{ hadController: boolean, refreshing: boolean }} state
+ * @returns {{ shouldReload: boolean, nextRefreshing: boolean }}
+ */
+export function decideControllerChangeReload(state) {
+ const hadController = Boolean(state?.hadController);
+ const refreshing = Boolean(state?.refreshing);
+ if (!hadController || refreshing) {
+ return { shouldReload: false, nextRefreshing: refreshing };
+ }
+ return { shouldReload: true, nextRefreshing: true };
+}
+
+/**
+ * @param {unknown} error
+ * @returns {boolean}
+ */
+export function isIgnorableServiceWorkerRegistrationError(error) {
+ const errorMessage = error && typeof error === "object" && "message" in error ? String(error.message || "") : "";
+ const errorName = error && typeof error === "object" && "name" in error ? String(error.name || "") : "";
+ return (
+ errorName === "SecurityError" ||
+ errorMessage.includes("SSL certificate") ||
+ errorMessage.includes("certificate")
+ );
+}
+
+/**
+ * Registration options that keep SW script discovery fresh.
+ * @returns {{ updateViaCache: "none" }}
+ */
+export function serviceWorkerRegisterOptions() {
+ return { updateViaCache: "none" };
+}

diff --git a/meshchatx/src/frontend/js/pwa/swShellRuntime.js b/meshchatx/src/frontend/js/pwa/swShellRuntime.js
new file mode 100644
index 00000000..04524dd7
--- /dev/null
+++ b/meshchatx/src/frontend/js/pwa/swShellRuntime.js
@@ -0,0 +1,242 @@
+// SPDX-License-Identifier: 0BSD
+
+import { NAV_NETWORK_TIMEOUT_MS, SHELL_CACHE_PREFIX, classifyShellRequest } from "./swCachePolicy.js";
+
+export const SHELL_FALLBACK_URL = "/";
+export const UPDATE_MESSAGE_TYPE = "meshchatx-sw-updated";
+
+/**
+ * Independent accept/reject oracle for shell caching.
+ * Predicts strategy from raw inputs without calling classifyShellRequest.
+ * @param {{ method?: string, pathname?: string, mode?: string, destination?: string, accept?: string }} input
+ * @returns {"bypass"|"asset"|"navigation"|"shell-helper"|"network-only"}
+ */
+export function oracleExpectedStrategy(input) {
+ const method = input.method || "GET";
+ const pathname = String(input.pathname || "/");
+ if (method !== "GET" && method !== "HEAD") {
+ return "bypass";
+ }
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
+ return "bypass";
+ }
+ if (pathname === "/ws" || pathname.startsWith("/ws/")) {
+ return "bypass";
+ }
+ if (pathname === "/service-worker.js") {
+ return "bypass";
+ }
+ if (pathname === "/assets" || pathname.startsWith("/assets/")) {
+ return "asset";
+ }
+ const navigate =
+ input.mode === "navigate" ||
+ input.destination === "document" ||
+ (typeof input.accept === "string" && input.accept.includes("text/html")) ||
+ pathname === "/" ||
+ pathname === "/index.html";
+ if (navigate) {
+ return "navigation";
+ }
+ if (
+ pathname === "/boot-theme.js" ||
+ pathname === "/manifest.json" ||
+ pathname === "/favicons" ||
+ pathname.startsWith("/favicons/") ||
+ /favicon/i.test(pathname)
+ ) {
+ return "shell-helper";
+ }
+ return "network-only";
+}
+
+/**
+ * @param {string[]} cacheKeys
+ * @param {string} prefix
+ * @param {string} currentName
+ * @returns {string[]}
+ */
+export function selectCachesToDelete(cacheKeys, prefix, currentName) {
+ const keys = Array.isArray(cacheKeys) ? cacheKeys : [];
+ return keys.filter((key) => typeof key === "string" && key.startsWith(prefix) && key !== currentName);
+}
+
+/**
+ * @param {Iterable<string>} urls
+ * @returns {string[]}
+ */
+export function findForbiddenCachedUrls(urls) {
+ const leaked = [];
+ for (const raw of urls || []) {
+ let pathname = String(raw || "");
+ try {
+ if (pathname.includes("://")) {
+ pathname = new URL(pathname).pathname;
+ }
+ } catch {
+ // keep raw
+ }
+ if (pathname === "/api" || pathname.startsWith("/api/") || pathname === "/ws" || pathname.startsWith("/ws/")) {
+ leaked.push(pathname);
+ }
+ }
+ return leaked;
+}
+
+/**
+ * Create injectable shell cache strategies for the service worker (and tests).
+ * @param {{
+ * caches: CacheStorage,
+ * fetch: typeof fetch,
+ * cacheName: string,
+ * origin: string,
+ * shellFallbackUrl?: string,
+ * navTimeoutMs?: number,
+ * setTimeout?: typeof setTimeout,
+ * clearTimeout?: typeof clearTimeout,
+ * }} options
+ */
+export function createShellRuntime(options) {
+ const cachesApi = options.caches;
+ const fetchFn = options.fetch;
+ const cacheName = options.cacheName;
+ const origin = options.origin;
+ const shellFallbackUrl = options.shellFallbackUrl || SHELL_FALLBACK_URL;
+ const navTimeoutMs = options.navTimeoutMs ?? NAV_NETWORK_TIMEOUT_MS;
+ const setTimeoutFn = options.setTimeout || setTimeout;
+ const clearTimeoutFn = options.clearTimeout || clearTimeout;
+
+ async function putInCache(request, response) {
+ if (!response || !response.ok) {
+ return;
+ }
+ const cache = await cachesApi.open(cacheName);
+ await cache.put(request, response);
+ }
+
+ async function cacheShellSnapshot(response) {
+ if (!response || !response.ok) {
+ return;
+ }
+ const cache = await cachesApi.open(cacheName);
+ await cache.put(shellFallbackUrl, response.clone());
+ }
+
+ async function networkWithTimeout(request, timeoutMs) {
+ const controller = new AbortController();
+ const timer = setTimeoutFn(() => controller.abort(), timeoutMs);
+ try {
+ return await fetchFn(request, { signal: controller.signal });
+ } finally {
+ clearTimeoutFn(timer);
+ }
+ }
+
+ async function cacheFirst(request) {
+ const cached = await cachesApi.match(request);
+ if (cached) {
+ return cached;
+ }
+ const response = await fetchFn(request);
+ if (response && response.ok) {
+ await putInCache(request, response.clone());
+ }
+ return response;
+ }
+
+ async function staleWhileRevalidate(request) {
+ const cache = await cachesApi.open(cacheName);
+ const cached = await cache.match(request);
+ const networkPromise = fetchFn(request)
+ .then((response) => {
+ if (response && response.ok) {
+ void cache.put(request, response.clone());
+ }
+ return response;
+ })
+ .catch(() => null);
+ if (cached) {
+ void networkPromise;
+ return cached;
+ }
+ const networkResponse = await networkPromise;
+ if (networkResponse) {
+ return networkResponse;
+ }
+ throw new Error("shell helper unavailable");
+ }
+
+ async function networkFirstNavigation(eventLike) {
+ const request = eventLike.request;
+ try {
+ let response = null;
+ if (eventLike.preloadResponse) {
+ response = await eventLike.preloadResponse;
+ }
+ if (!response) {
+ response = await networkWithTimeout(request, navTimeoutMs);
+ }
+ if (response && response.ok) {
+ await cacheShellSnapshot(response.clone());
+ return response;
+ }
+ } catch {
+ // fall through to cache
+ }
+ const cached = (await cachesApi.match(shellFallbackUrl)) || (await cachesApi.match(request));
+ if (cached) {
+ return cached;
+ }
+ return fetchFn(request);
+ }
+
+ async function pruneOldShellCaches() {
+ const keys = await cachesApi.keys();
+ const doomed = selectCachesToDelete(keys, SHELL_CACHE_PREFIX, cacheName);
+ await Promise.all(doomed.map((key) => cachesApi.delete(key)));
+ return doomed;
+ }
+
+ /**
+ * @param {Request} request
+ * @param {{ preloadResponse?: Promise<Response|null> }} [eventExtras]
+ * @returns {Promise<Response>|null} null means network bypass (do not respondWith)
+ */
+ function resolveFetch(request, eventExtras = {}) {
+ let url;
+ try {
+ url = new URL(request.url);
+ } catch {
+ return null;
+ }
+ if (url.origin !== origin) {
+ return null;
+ }
+ const kind = classifyShellRequest(request, url);
+ if (kind === "bypass" || kind === "network-only") {
+ return null;
+ }
+ if (kind === "asset") {
+ return cacheFirst(request);
+ }
+ if (kind === "shell-helper") {
+ return staleWhileRevalidate(request);
+ }
+ if (kind === "navigation") {
+ return networkFirstNavigation({ request, preloadResponse: eventExtras.preloadResponse });
+ }
+ return null;
+ }
+
+ return {
+ cacheFirst,
+ staleWhileRevalidate,
+ networkFirstNavigation,
+ pruneOldShellCaches,
+ putInCache,
+ cacheShellSnapshot,
+ resolveFetch,
+ cacheName,
+ shellFallbackUrl,
+ };
+}

diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index 989ecade..129bc8ca 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -19,6 +19,12 @@ import { installWsEventBridge } from "./js/registries/wsEventBridge.js";
import { pluginHost } from "./js/plugins/PluginHost.js";
import GlobalState from "./js/GlobalState.js";
import { recoveryRouteForNetworkError } from "./js/networkRecovery.js";
+import ElectronUtils from "./js/ElectronUtils.js";
+import {
+ decideControllerChangeReload,
+ isIgnorableServiceWorkerRegistrationError,
+ serviceWorkerRegisterOptions,
+} from "./js/pwa/swClientRegister.js";
import "./js/HeapMonitor.js";
registerCoreContributions();
@@ -427,18 +433,41 @@ if (networkReady) {
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
return;
}
- navigator.serviceWorker.register("/service-worker.js").catch((error) => {
- const errorMessage = error.message || "";
- const errorName = error.name || "";
- if (
- errorName === "SecurityError" ||
- errorMessage.includes("SSL certificate") ||
- errorMessage.includes("certificate")
- ) {
- return;
+ if (ElectronUtils.isElectron()) {
+ return;
+ }
+ let refreshing = false;
+ const hadController = Boolean(navigator.serviceWorker.controller);
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
+ const decision = decideControllerChangeReload({ hadController, refreshing });
+ refreshing = decision.nextRefreshing;
+ if (decision.shouldReload) {
+ window.location.reload();
}
- console.debug("Service worker registration failed:", error);
});
+ navigator.serviceWorker
+ .register("/service-worker.js", serviceWorkerRegisterOptions())
+ .then((registration) => {
+ const requestUpdate = () => {
+ try {
+ void registration.update();
+ } catch {
+ // ignore update failures
+ }
+ };
+ document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "visible") {
+ requestUpdate();
+ }
+ });
+ requestUpdate();
+ })
+ .catch((error) => {
+ if (isIgnorableServiceWorkerRegistrationError(error)) {
+ return;
+ }
+ console.debug("Service worker registration failed:", error);
+ });
}
function removeBootSplash(splash) {

diff --git a/meshchatx/src/frontend/public/manifest.json b/meshchatx/src/frontend/public/manifest.json
index 9a9294b1..876b39f0 100644
--- a/meshchatx/src/frontend/public/manifest.json
+++ b/meshchatx/src/frontend/public/manifest.json
@@ -1,7 +1,7 @@
{
- "name": "MeshChat",
- "short_name": "MeshChat",
- "description": "A simple mesh network communications app powered by the Reticulum Network Stack.",
+ "name": "MeshChatX",
+ "short_name": "MeshChatX",
+ "description": "A local-first mesh network communications app powered by the Reticulum Network Stack.",
"scope": "/",
"start_url": "/",
"icons": [
@@ -12,6 +12,6 @@
}
],
"display": "standalone",
- "theme_color": "#FFFFFF",
- "background_color": "#FFFFFF"
+ "theme_color": "#09090b",
+ "background_color": "#09090b"
}

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md b/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md
index 1eb409b1..4c05c2a9 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md
@@ -44,6 +44,8 @@ meshchatx/meshchat.py (aiohttp server)
The same backend code powers Docker images, Python wheels, Linux packages, Electron desktop builds, and the Android APK. Packaging differs. Behaviour is intended to stay consistent.
+In a regular browser (including headless or LAN installs), MeshChatX may register a service worker that caches hashed UI assets and the app shell so repeat loads are faster and a hard refresh can still show the boot splash while the local backend restarts. Mesh messaging, identity, and API data still require the Python backend. Electron does not use this service worker path.
+
## Main areas of the UI
| Area | Route | Purpose |
@@ -92,6 +94,6 @@ Legacy upstream data may still exist under `~/.reticulum-meshchat/`. Migration t
- **Architecture and design** explains backend managers, identity scoping, and the API model.
- **LXMF messaging** and **Audio calls** describe day-to-day communication features.
- **Reticulum interfaces** explains how your node joins the mesh.
-- Platform guides under **Platform guides** cover Raspberry Pi, Android Termux, Meta Quest, and Linux sandboxing.
+- Platform guides under **Platform guides** cover Raspberry Pi, Android Termux, Meta Quest, Linux sandboxing, and Windows AppContainer sandboxing.
For protocol-level detail, open the **Reticulum** tab in Documentation or visit the [Reticulum manual](https://reticulum.network/manual/) online.

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/windows-sandbox.md b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/windows-sandbox.md
deleted file mode 100644
index c433218f..00000000
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/windows-sandbox.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# Windows AppContainer sandbox (Landlock equivalent)
-
-MeshChatX on **Windows 10 and Windows 11** can run the backend inside a **Less Privileged AppContainer (LPAC)**. This is the Windows counterpart to Linux **Landlock**: the process loses ambient write access to the user profile and only keeps write access under explicitly granted directories.
-
-Unlike Landlock, Windows cannot lock down an already-running process the same way. Electron therefore starts the frozen backend through a small launcher module that:
-
-1. Creates or reuses the `MeshChatX.Backend` AppContainer profile
-2. Grants the package SID read/write ACLs on storage, Reticulum config, logs, temp, and app-owned exchange folders
-3. Grants read/execute on the packaged backend tree (needed under LPAC)
-4. Starts `ReticulumMeshChatX.exe` inside the container with network capabilities for Reticulum
-5. Waits for exit and revokes those ACL grants
-
-## What is allowed
-
-**Writable**
-
-- MeshChatX storage (`--storage-dir` / `%USERPROFILE%\.reticulum-meshchatx` or portable sibling)
-- Reticulum config (`--reticulum-config-dir`)
-- Log directory (`MESHCHAT_LOG_DIR`, usually `storage/logs`)
-- Process temp (`%TEMP%` / `%TMP%`)
-- App-owned exchange folders (created if missing):
- - `Documents\MeshChatX`
- - `Downloads\MeshChatX`
- - `Pictures\MeshChatX`
-
-These exchange folders are for attachments and exports. The sandbox does **not** grant access to all of Documents, Downloads, or Pictures.
-
-Electron sets the default download path to `Downloads\MeshChatX` so UI saves land in the same exchange dir the backend can use.
-
-**Readable (execute tree)**
-
-- Packaged backend directory next to `ReticulumMeshChatX.exe`
-
-**Network**
-
-- Internet and private-network AppContainer capabilities are granted so RNS TCP/UDP interfaces keep working. There is no per-host or per-port firewall in this layer.
-
-**Not writable**
-
-- Desktop, and the rest of Documents / Downloads / Pictures outside the `MeshChatX` subfolders
-- Arbitrary profile paths and drive roots
-
-Attaching a file from elsewhere still works through the Electron/renderer file picker (bytes uploaded over local HTTPS). The backend does not need to open the original Documents path for that flow.
-
-## Environment override
-
-| Env | Effect |
-| --- | --- |
-| unset | Auto-enable on Windows when AppContainer APIs are available (Electron desktop default on) |
-| `MESHCHAT_APPCONTAINER=0` | Disable (direct backend spawn, no launcher) |
-| `MESHCHAT_APPCONTAINER=1` | Force AppContainer. Launch fails hard if APIs or CreateProcess fail |
-
-The sandboxed child sets `MESHCHAT_APPCONTAINER_CHILD=1`. Settings → Network exposure shows AppContainer status next to Landlock/Seccomp.
-
-## Portable installs
-
-When `PORTABLE_EXECUTABLE_DIR` is set, storage and Reticulum dirs live beside the portable exe. The launcher grants ACLs on those portable paths, not only under `%USERPROFILE%`. Exchange folders still use the current user's Documents/Downloads/Pictures Known Folders (including OneDrive redirects when available).
-
-## SQLite
-
-Under AppContainer (same as Landlock), MeshChatX keeps `PRAGMA temp_store=MEMORY` during memory pressure so spill files are not required outside the jail.
-
-## Disable / troubleshoot
-
-- Set `MESHCHAT_APPCONTAINER=0` and restart if a DLL fails to load under LPAC.
-- If forced mode fails, check logs for CreateProcess or ACL errors.
-- Storage directory changes require a backend restart so ACL grants match the new roots.
-- Orphan cleanup uses `taskkill /T` on the launcher PID so the AppContainer child is included.
-
-## Relation to other layers
-
-- Electron renderer still uses Chromium sandbox / fuses.
-- Plugin RSG and path jail remain in force inside the container.
-- Linux Landlock and Seccomp are unchanged and unused on Windows.
-
-## Manual smoke checklist (Win10 / Win11)
-
-1. Packaged app starts and `/api/v1/status` reports `appcontainer_active: true`
-2. Local HTTPS UI on port 9337 loads
-3. Messages and RNS interfaces still work
-4. Writing a probe file under storage succeeds
-5. Writing under `Documents\MeshChatX` succeeds
-6. Writing under Desktop or bare Documents from a debug hook fails
-7. Quitting Electron stops launcher and child

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/manifest.json b/meshchatx/src/frontend/public/meshchatx-docs/manifest.json
index 706558e3..8a35c4f8 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/manifest.json
+++ b/meshchatx/src/frontend/public/meshchatx-docs/manifest.json
@@ -108,11 +108,6 @@
"path": "en/platform-guides/linux-sandbox.md",
"lang": "en",
"title": { "en": "Linux sandboxing" }
- },
- {
- "path": "en/platform-guides/windows-sandbox.md",
- "lang": "en",
- "title": { "en": "Windows AppContainer sandbox" }
}
]
}

diff --git a/meshchatx/src/frontend/public/service-worker.js b/meshchatx/src/frontend/public/service-worker.js
index 95ab8ffe..5c848422 100644
--- a/meshchatx/src/frontend/public/service-worker.js
+++ b/meshchatx/src/frontend/public/service-worker.js
@@ -1,2 +1,447 @@
-// Service worker required for PWA installability.
-// A fetch handler is not required - only the service worker registration is needed.
+// SPDX-License-Identifier: 0BSD
+
+/* eslint-disable no-restricted-globals */
+
+/* Generated MeshChatX service worker. Do not edit by hand. */
+
+/** Prefix for versioned Cache Storage buckets. */
+const SHELL_CACHE_PREFIX = "meshchatx-shell-";
+
+/** Default navigation network-first timeout in milliseconds. */
+const NAV_NETWORK_TIMEOUT_MS = 2000;
+
+/**
+ * @param {string} buildId
+ * @returns {string}
+ */
+function cacheNameForBuild(buildId) {
+ const id = String(buildId || "dev").replace(/[^a-zA-Z0-9._-]/g, "_");
+ return `${SHELL_CACHE_PREFIX}v${id}`;
+}
+
+/**
+ * @param {string} pathname
+ * @returns {boolean}
+ */
+function isApiPath(pathname) {
+ const path = String(pathname || "");
+ return path === "/api" || path.startsWith("/api/");
+}
+
+/**
+ * Vite content-hashed build assets under /assets/.
+ * @param {string} pathname
+ * @returns {boolean}
+ */
+function isHashedAssetPath(pathname) {
+ const path = String(pathname || "");
+ return path === "/assets" || path.startsWith("/assets/");
+}
+
+/**
+ * Static shell helpers that may use stale-while-revalidate.
+ * @param {string} pathname
+ * @returns {boolean}
+ */
+function isShellHelperPath(pathname) {
+ const path = String(pathname || "");
+ if (path === "/boot-theme.js" || path === "/manifest.json") {
+ return true;
+ }
+ if (path === "/favicons" || path.startsWith("/favicons/")) {
+ return true;
+ }
+ if (/favicon/i.test(path)) {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * @param {Request} request
+ * @returns {boolean}
+ */
+function isNavigationRequest(request) {
+ if (!request || typeof request !== "object") {
+ return false;
+ }
+ if (request.mode === "navigate") {
+ return true;
+ }
+ const dest = request.destination;
+ if (dest === "document") {
+ return true;
+ }
+ const accept = request.headers?.get?.("accept") || "";
+ return typeof accept === "string" && accept.includes("text/html");
+}
+
+/**
+ * True when the service worker must not use Cache Storage for this request.
+ * @param {Request} request
+ * @param {URL} [url]
+ * @returns {boolean}
+ */
+function shouldBypassCache(request, url) {
+ if (!request || (request.method !== "GET" && request.method !== "HEAD")) {
+ return true;
+ }
+ let pathname = "/";
+ if (url && typeof url.pathname === "string") {
+ pathname = url.pathname;
+ } else if (typeof request.url === "string") {
+ try {
+ pathname = new URL(request.url).pathname;
+ } catch {
+ return true;
+ }
+ }
+ if (isApiPath(pathname)) {
+ return true;
+ }
+ if (pathname === "/ws" || pathname.startsWith("/ws/")) {
+ return true;
+ }
+ if (pathname === "/service-worker.js") {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Classify a same-origin GET for shell caching strategy selection.
+ * @param {Request} request
+ * @param {URL} url
+ * @returns {"bypass"|"asset"|"navigation"|"shell-helper"|"network-only"}
+ */
+function classifyShellRequest(request, url) {
+ if (shouldBypassCache(request, url)) {
+ return "bypass";
+ }
+ const pathname = url.pathname;
+ if (isHashedAssetPath(pathname)) {
+ return "asset";
+ }
+ if (isNavigationRequest(request) || pathname === "/" || pathname === "/index.html") {
+ return "navigation";
+ }
+ if (isShellHelperPath(pathname)) {
+ return "shell-helper";
+ }
+ return "network-only";
+}
+
+const SHELL_FALLBACK_URL = "/";
+const UPDATE_MESSAGE_TYPE = "meshchatx-sw-updated";
+
+/**
+ * Independent accept/reject oracle for shell caching.
+ * Predicts strategy from raw inputs without calling classifyShellRequest.
+ * @param {{ method?: string, pathname?: string, mode?: string, destination?: string, accept?: string }} input
+ * @returns {"bypass"|"asset"|"navigation"|"shell-helper"|"network-only"}
+ */
+function oracleExpectedStrategy(input) {
+ const method = input.method || "GET";
+ const pathname = String(input.pathname || "/");
+ if (method !== "GET" && method !== "HEAD") {
+ return "bypass";
+ }
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
+ return "bypass";
+ }
+ if (pathname === "/ws" || pathname.startsWith("/ws/")) {
+ return "bypass";
+ }
+ if (pathname === "/service-worker.js") {
+ return "bypass";
+ }
+ if (pathname === "/assets" || pathname.startsWith("/assets/")) {
+ return "asset";
+ }
+ const navigate =
+ input.mode === "navigate" ||
+ input.destination === "document" ||
+ (typeof input.accept === "string" && input.accept.includes("text/html")) ||
+ pathname === "/" ||
+ pathname === "/index.html";
+ if (navigate) {
+ return "navigation";
+ }
+ if (
+ pathname === "/boot-theme.js" ||
+ pathname === "/manifest.json" ||
+ pathname === "/favicons" ||
+ pathname.startsWith("/favicons/") ||
+ /favicon/i.test(pathname)
+ ) {
+ return "shell-helper";
+ }
+ return "network-only";
+}
+
+/**
+ * @param {string[]} cacheKeys
+ * @param {string} prefix
+ * @param {string} currentName
+ * @returns {string[]}
+ */
+function selectCachesToDelete(cacheKeys, prefix, currentName) {
+ const keys = Array.isArray(cacheKeys) ? cacheKeys : [];
+ return keys.filter((key) => typeof key === "string" && key.startsWith(prefix) && key !== currentName);
+}
+
+/**
+ * @param {Iterable<string>} urls
+ * @returns {string[]}
+ */
+function findForbiddenCachedUrls(urls) {
+ const leaked = [];
+ for (const raw of urls || []) {
+ let pathname = String(raw || "");
+ try {
+ if (pathname.includes("://")) {
+ pathname = new URL(pathname).pathname;
+ }
+ } catch {
+ // keep raw
+ }
+ if (pathname === "/api" || pathname.startsWith("/api/") || pathname === "/ws" || pathname.startsWith("/ws/")) {
+ leaked.push(pathname);
+ }
+ }
+ return leaked;
+}
+
+/**
+ * Create injectable shell cache strategies for the service worker (and tests).
+ * @param {{
+ * caches: CacheStorage,
+ * fetch: typeof fetch,
+ * cacheName: string,
+ * origin: string,
+ * shellFallbackUrl?: string,
+ * navTimeoutMs?: number,
+ * setTimeout?: typeof setTimeout,
+ * clearTimeout?: typeof clearTimeout,
+ * }} options
+ */
+function createShellRuntime(options) {
+ const cachesApi = options.caches;
+ const fetchFn = options.fetch;
+ const cacheName = options.cacheName;
+ const origin = options.origin;
+ const shellFallbackUrl = options.shellFallbackUrl || SHELL_FALLBACK_URL;
+ const navTimeoutMs = options.navTimeoutMs ?? NAV_NETWORK_TIMEOUT_MS;
+ const setTimeoutFn = options.setTimeout || setTimeout;
+ const clearTimeoutFn = options.clearTimeout || clearTimeout;
+
+ async function putInCache(request, response) {
+ if (!response || !response.ok) {
+ return;
+ }
+ const cache = await cachesApi.open(cacheName);
+ await cache.put(request, response);
+ }
+
+ async function cacheShellSnapshot(response) {
+ if (!response || !response.ok) {
+ return;
+ }
+ const cache = await cachesApi.open(cacheName);
+ await cache.put(shellFallbackUrl, response.clone());
+ }
+
+ async function networkWithTimeout(request, timeoutMs) {
+ const controller = new AbortController();
+ const timer = setTimeoutFn(() => controller.abort(), timeoutMs);
+ try {
+ return await fetchFn(request, { signal: controller.signal });
+ } finally {
+ clearTimeoutFn(timer);
+ }
+ }
+
+ async function cacheFirst(request) {
+ const cached = await cachesApi.match(request);
+ if (cached) {
+ return cached;
+ }
+ const response = await fetchFn(request);
+ if (response && response.ok) {
+ await putInCache(request, response.clone());
+ }
+ return response;
+ }
+
+ async function staleWhileRevalidate(request) {
+ const cache = await cachesApi.open(cacheName);
+ const cached = await cache.match(request);
+ const networkPromise = fetchFn(request)
+ .then((response) => {
+ if (response && response.ok) {
+ void cache.put(request, response.clone());
+ }
+ return response;
+ })
+ .catch(() => null);
+ if (cached) {
+ void networkPromise;
+ return cached;
+ }
+ const networkResponse = await networkPromise;
+ if (networkResponse) {
+ return networkResponse;
+ }
+ throw new Error("shell helper unavailable");
+ }
+
+ async function networkFirstNavigation(eventLike) {
+ const request = eventLike.request;
+ try {
+ let response = null;
+ if (eventLike.preloadResponse) {
+ response = await eventLike.preloadResponse;
+ }
+ if (!response) {
+ response = await networkWithTimeout(request, navTimeoutMs);
+ }
+ if (response && response.ok) {
+ await cacheShellSnapshot(response.clone());
+ return response;
+ }
+ } catch {
+ // fall through to cache
+ }
+ const cached = (await cachesApi.match(shellFallbackUrl)) || (await cachesApi.match(request));
+ if (cached) {
+ return cached;
+ }
+ return fetchFn(request);
+ }
+
+ async function pruneOldShellCaches() {
+ const keys = await cachesApi.keys();
+ const doomed = selectCachesToDelete(keys, SHELL_CACHE_PREFIX, cacheName);
+ await Promise.all(doomed.map((key) => cachesApi.delete(key)));
+ return doomed;
+ }
+
+ /**
+ * @param {Request} request
+ * @param {{ preloadResponse?: Promise<Response|null> }} [eventExtras]
+ * @returns {Promise<Response>|null} null means network bypass (do not respondWith)
+ */
+ function resolveFetch(request, eventExtras = {}) {
+ let url;
+ try {
+ url = new URL(request.url);
+ } catch {
+ return null;
+ }
+ if (url.origin !== origin) {
+ return null;
+ }
+ const kind = classifyShellRequest(request, url);
+ if (kind === "bypass" || kind === "network-only") {
+ return null;
+ }
+ if (kind === "asset") {
+ return cacheFirst(request);
+ }
+ if (kind === "shell-helper") {
+ return staleWhileRevalidate(request);
+ }
+ if (kind === "navigation") {
+ return networkFirstNavigation({ request, preloadResponse: eventExtras.preloadResponse });
+ }
+ return null;
+ }
+
+ return {
+ cacheFirst,
+ staleWhileRevalidate,
+ networkFirstNavigation,
+ pruneOldShellCaches,
+ putInCache,
+ cacheShellSnapshot,
+ resolveFetch,
+ cacheName,
+ shellFallbackUrl,
+ };
+}
+
+// SPDX-License-Identifier: 0BSD
+/**
+ * MeshChatX app-shell service worker bootstrap.
+ * Preceded at build time by inlined swCachePolicy.js + swShellRuntime.js.
+ * Placeholders dev and ["/","/boot-theme.js","/manifest.json","/favicons/favicon-512x512.png"] are replaced.
+ */
+
+const BUILD_ID = "dev";
+const PRECACHE_URLS = ["/","/boot-theme.js","/manifest.json","/favicons/favicon-512x512.png"];
+const CACHE_NAME = cacheNameForBuild(BUILD_ID);
+
+const runtime = createShellRuntime({
+ caches: self.caches,
+ fetch: (...args) => self.fetch(...args),
+ cacheName: CACHE_NAME,
+ origin: self.location.origin,
+ shellFallbackUrl: SHELL_FALLBACK_URL,
+ navTimeoutMs: NAV_NETWORK_TIMEOUT_MS,
+});
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ (async () => {
+ const cache = await self.caches.open(CACHE_NAME);
+ const urls = Array.isArray(PRECACHE_URLS) ? PRECACHE_URLS : [];
+ for (const url of urls) {
+ try {
+ await cache.add(url);
+ } catch {
+ // Skip missing optional assets during install
+ }
+ }
+ await self.skipWaiting();
+ })()
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ (async () => {
+ if (self.registration && self.registration.navigationPreload) {
+ try {
+ await self.registration.navigationPreload.enable();
+ } catch {
+ // navigation preload unsupported or denied
+ }
+ }
+ await runtime.pruneOldShellCaches();
+ await self.clients.claim();
+ const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
+ for (const client of clients) {
+ client.postMessage({ type: UPDATE_MESSAGE_TYPE, buildId: BUILD_ID });
+ }
+ })()
+ );
+});
+
+self.addEventListener("message", (event) => {
+ const data = event.data;
+ if (!data || typeof data !== "object") {
+ return;
+ }
+ if (data.type === "meshchatx-sw-skip-waiting") {
+ void self.skipWaiting();
+ }
+});
+
+self.addEventListener("fetch", (event) => {
+ const handled = runtime.resolveFetch(event.request, {
+ preloadResponse: event.preloadResponse,
+ });
+ if (handled) {
+ event.respondWith(handled);
+ }
+});

diff --git a/meshchatx/src/frontend/sw/service-worker.template.js b/meshchatx/src/frontend/sw/service-worker.template.js
new file mode 100644
index 00000000..0274e560
--- /dev/null
+++ b/meshchatx/src/frontend/sw/service-worker.template.js
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: 0BSD
+/* global __MESHCHATX_SW_PRECACHE_JSON__, cacheNameForBuild, createShellRuntime, SHELL_FALLBACK_URL, NAV_NETWORK_TIMEOUT_MS, UPDATE_MESSAGE_TYPE */
+/**
+ * MeshChatX app-shell service worker bootstrap.
+ * Preceded at build time by inlined swCachePolicy.js + swShellRuntime.js.
+ * Placeholders __MESHCHATX_SW_BUILD_ID__ and __MESHCHATX_SW_PRECACHE_JSON__ are replaced.
+ */
+
+const BUILD_ID = "__MESHCHATX_SW_BUILD_ID__";
+const PRECACHE_URLS = __MESHCHATX_SW_PRECACHE_JSON__;
+const CACHE_NAME = cacheNameForBuild(BUILD_ID);
+
+const runtime = createShellRuntime({
+ caches: self.caches,
+ fetch: (...args) => self.fetch(...args),
+ cacheName: CACHE_NAME,
+ origin: self.location.origin,
+ shellFallbackUrl: SHELL_FALLBACK_URL,
+ navTimeoutMs: NAV_NETWORK_TIMEOUT_MS,
+});
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ (async () => {
+ const cache = await self.caches.open(CACHE_NAME);
+ const urls = Array.isArray(PRECACHE_URLS) ? PRECACHE_URLS : [];
+ for (const url of urls) {
+ try {
+ await cache.add(url);
+ } catch {
+ // Skip missing optional assets during install
+ }
+ }
+ await self.skipWaiting();
+ })()
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ (async () => {
+ if (self.registration && self.registration.navigationPreload) {
+ try {
+ await self.registration.navigationPreload.enable();
+ } catch {
+ // navigation preload unsupported or denied
+ }
+ }
+ await runtime.pruneOldShellCaches();
+ await self.clients.claim();
+ const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
+ for (const client of clients) {
+ client.postMessage({ type: UPDATE_MESSAGE_TYPE, buildId: BUILD_ID });
+ }
+ })()
+ );
+});
+
+self.addEventListener("message", (event) => {
+ const data = event.data;
+ if (!data || typeof data !== "object") {
+ return;
+ }
+ if (data.type === "meshchatx-sw-skip-waiting") {
+ void self.skipWaiting();
+ }
+});
+
+self.addEventListener("fetch", (event) => {
+ const handled = runtime.resolveFetch(event.request, {
+ preloadResponse: event.preloadResponse,
+ });
+ if (handled) {
+ event.respondWith(handled);
+ }
+});

diff --git a/pyrightconfig.json b/pyrightconfig.json
index e072d1e0..24bc7b23 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -1,6 +1,12 @@
{
"include": ["meshchatx/src/backend", "meshchatx/src/env_utils.py"],
- "exclude": ["**/vendor/**", "**/build/**", "**/node_modules/**", "**/__pycache__/**"],
+ "exclude": [
+ "**/vendor/**",
+ "**/build/**",
+ "**/node_modules/**",
+ "**/__pycache__/**",
+ "meshchatx/src/backend/data/interfaces/**"
+ ],
"extraPaths": [".", "vendor/lxmfy", "vendor/rns_filesync"],
"stubPath": "typings",
"pythonVersion": "3.11",

diff --git a/scripts/build/generate_service_worker.mjs b/scripts/build/generate_service_worker.mjs
new file mode 100644
index 00000000..1cf52a6b
--- /dev/null
+++ b/scripts/build/generate_service_worker.mjs
@@ -0,0 +1,180 @@
+// SPDX-License-Identifier: 0BSD
+/**
+ * Generate MeshChatX service-worker.js from shared PWA modules + bootstrap template.
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = path.resolve(__dirname, "../..");
+
+export const SW_TEMPLATE_PATH = path.join(
+ REPO_ROOT,
+ "meshchatx",
+ "src",
+ "frontend",
+ "sw",
+ "service-worker.template.js"
+);
+
+export const SW_POLICY_PATH = path.join(
+ REPO_ROOT,
+ "meshchatx",
+ "src",
+ "frontend",
+ "js",
+ "pwa",
+ "swCachePolicy.js"
+);
+
+export const SW_RUNTIME_PATH = path.join(
+ REPO_ROOT,
+ "meshchatx",
+ "src",
+ "frontend",
+ "js",
+ "pwa",
+ "swShellRuntime.js"
+);
+
+export const SW_PUBLIC_PATH = path.join(
+ REPO_ROOT,
+ "meshchatx",
+ "src",
+ "frontend",
+ "public",
+ "service-worker.js"
+);
+
+export const DEFAULT_SHELL_PRECACHE = [
+ "/",
+ "/boot-theme.js",
+ "/manifest.json",
+ "/favicons/favicon-512x512.png",
+];
+
+/**
+ * Strip ESM import/export so modules can be inlined into a classic service worker.
+ * @param {string} source
+ * @returns {string}
+ */
+export function stripEsmForServiceWorker(source) {
+ return String(source || "")
+ .replace(/^import\s+[\s\S]*?from\s+["'][^"']+["']\s*;?\s*$/gm, "")
+ .replace(/^export\s+async\s+function\s+/gm, "async function ")
+ .replace(/^export\s+function\s+/gm, "function ")
+ .replace(/^export\s+const\s+/gm, "const ")
+ .replace(/^export\s+\{[^}]*\}\s*;?\s*$/gm, "")
+ .replace(/^\/\/ SPDX-License-Identifier:.*$/gm, "")
+ .trim();
+}
+
+/**
+ * @param {string} assetsDir absolute path to meshchatx/public/assets
+ * @returns {string[]}
+ */
+export function collectAssetPrecacheUrls(assetsDir) {
+ const urls = [];
+ if (!assetsDir || !fs.existsSync(assetsDir)) {
+ return urls;
+ }
+ const walk = (dir, prefix) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const abs = path.join(dir, entry.name);
+ const rel = `${prefix}/${entry.name}`.replace(/\\/g, "/");
+ if (entry.isDirectory()) {
+ walk(abs, rel);
+ } else if (entry.isFile()) {
+ urls.push(rel);
+ }
+ }
+ };
+ walk(assetsDir, "/assets");
+ urls.sort();
+ return urls;
+}
+
+/**
+ * @param {{ buildId?: string, precacheUrls?: string[], templatePath?: string }} options
+ * @returns {string}
+ */
+export function renderServiceWorkerSource(options = {}) {
+ const templatePath = options.templatePath || SW_TEMPLATE_PATH;
+ const buildId = options.buildId || "dev";
+ const precacheUrls = Array.isArray(options.precacheUrls)
+ ? options.precacheUrls
+ : DEFAULT_SHELL_PRECACHE;
+ const template = fs.readFileSync(templatePath, "utf8");
+ if (!template.includes("__MESHCHATX_SW_BUILD_ID__") || !template.includes("__MESHCHATX_SW_PRECACHE_JSON__")) {
+ throw new Error("service-worker template missing injection placeholders");
+ }
+ const policy = stripEsmForServiceWorker(fs.readFileSync(SW_POLICY_PATH, "utf8"));
+ const runtime = stripEsmForServiceWorker(fs.readFileSync(SW_RUNTIME_PATH, "utf8"));
+ const bootstrap = template
+ .replaceAll("__MESHCHATX_SW_BUILD_ID__", String(buildId))
+ .replaceAll("__MESHCHATX_SW_PRECACHE_JSON__", JSON.stringify(precacheUrls));
+ return [
+ "// SPDX-License-Identifier: 0BSD",
+ "/* eslint-disable no-restricted-globals */",
+ "/* Generated MeshChatX service worker. Do not edit by hand. */",
+ policy,
+ runtime,
+ bootstrap,
+ ].join("\n\n");
+}
+
+/**
+ * @param {{
+ * buildId?: string,
+ * precacheUrls?: string[],
+ * outfile?: string,
+ * assetsDir?: string,
+ * }} options
+ * @returns {{ outfile: string, buildId: string, precacheUrls: string[] }}
+ */
+export function writeServiceWorker(options = {}) {
+ const outfile = options.outfile || SW_PUBLIC_PATH;
+ const buildId = options.buildId || "dev";
+ const fromAssets = options.assetsDir ? collectAssetPrecacheUrls(options.assetsDir) : [];
+ const base = Array.isArray(options.precacheUrls) ? options.precacheUrls : DEFAULT_SHELL_PRECACHE;
+ const precacheUrls = [...new Set([...base, ...fromAssets])];
+ const source = renderServiceWorkerSource({ buildId, precacheUrls });
+ fs.mkdirSync(path.dirname(outfile), { recursive: true });
+ fs.writeFileSync(outfile, source, "utf8");
+ return { outfile, buildId, precacheUrls };
+}
+
+/**
+ * Vite plugin: write a dev SW into public/ and a production SW into outDir after bundle.
+ * @param {{ buildId: string }} options
+ */
+export function meshchatxServiceWorkerPlugin(options) {
+ const buildId = options?.buildId || "dev";
+ return {
+ name: "meshchatx-service-worker",
+ buildStart() {
+ writeServiceWorker({
+ buildId: "dev",
+ precacheUrls: DEFAULT_SHELL_PRECACHE,
+ outfile: SW_PUBLIC_PATH,
+ });
+ },
+ closeBundle() {
+ const outDir = path.join(REPO_ROOT, "meshchatx", "public");
+ const assetsDir = path.join(outDir, "assets");
+ writeServiceWorker({
+ buildId,
+ assetsDir,
+ precacheUrls: DEFAULT_SHELL_PRECACHE,
+ outfile: path.join(outDir, "service-worker.js"),
+ });
+ writeServiceWorker({
+ buildId: "dev",
+ precacheUrls: DEFAULT_SHELL_PRECACHE,
+ outfile: SW_PUBLIC_PATH,
+ });
+ },
+ };
+}

diff --git a/scripts/ci/github-verify-frozen-sandbox.sh b/scripts/ci/github-verify-frozen-sandbox.sh
index 9d06e810..0bc5162d 100755
--- a/scripts/ci/github-verify-frozen-sandbox.sh
+++ b/scripts/ci/github-verify-frozen-sandbox.sh
@@ -20,25 +20,44 @@ if [[ ! -d "${BUILD_EXE}/lib" ]]; then
fi
LIB_DIR="${BUILD_EXE}/lib"
-REQUIRED=(
- "meshchatx/src/backend/landlock_sandbox.py"
- "meshchatx/src/backend/appcontainer_sandbox.py"
- "meshchatx/src/backend/appcontainer_launcher.py"
- "meshchatx/src/backend/seccomp_sandbox.py"
+LIBRARY_ZIP="${LIB_DIR}/library.zip"
+REQUIRED_STEMS=(
+ "landlock_sandbox"
+ "appcontainer_sandbox"
+ "appcontainer_launcher"
+ "seccomp_sandbox"
)
-missing=0
-for rel in "${REQUIRED[@]}"; do
- if [[ -f "${LIB_DIR}/${rel}" ]]; then
- echo "found ${rel}"
- continue
+module_present() {
+ local stem="$1"
+ local hit
+
+ hit="$(
+ find "${LIB_DIR}" -type f \
+ \( -name "${stem}.py" -o -name "${stem}.pyc" -o -name "${stem}.*.pyc" \) \
+ 2>/dev/null | head -n 1 || true
+ )"
+ if [[ -n "${hit}" ]]; then
+ echo "found ${hit#"${LIB_DIR}"/}"
+ return 0
fi
- LIBRARY_ZIP="${LIB_DIR}/library.zip"
- if [[ -f "${LIBRARY_ZIP}" ]] && unzip -l "${LIBRARY_ZIP}" | grep -Fq "${rel}"; then
- echo "found ${rel} in library.zip"
+
+ if [[ -f "${LIBRARY_ZIP}" ]]; then
+ if unzip -l "${LIBRARY_ZIP}" | grep -Eiq "${stem}\\.(py|pyc)([^[:alnum:_].]|$)"; then
+ echo "found ${stem} in library.zip"
+ return 0
+ fi
+ fi
+
+ return 1
+}
+
+missing=0
+for stem in "${REQUIRED_STEMS[@]}"; do
+ if module_present "${stem}"; then
continue
fi
- echo "missing sandbox module: ${rel}" >&2
+ echo "missing sandbox module: ${stem}" >&2
missing=1
done

diff --git a/tests/backend/api_json_contract_schemas.py b/tests/backend/api_json_contract_schemas.py
index 912470de..8ed3ffca 100644
--- a/tests/backend/api_json_contract_schemas.py
+++ b/tests/backend/api_json_contract_schemas.py
@@ -238,6 +238,7 @@ SELF_TEST_SCHEMA: dict = {
"imports_good",
"storage_lock_good",
"temp_fs_good",
+ "fs_sandbox_good",
"public_assets_good",
"lxmf_router_good",
"subprocess_good",
@@ -280,6 +281,7 @@ SELF_TEST_SCHEMA: dict = {
"imports_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"storage_lock_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"temp_fs_good": SELF_TEST_STATUS_ITEM_SCHEMA,
+ "fs_sandbox_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"public_assets_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"lxmf_router_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"subprocess_good": SELF_TEST_STATUS_ITEM_SCHEMA,

diff --git a/tests/backend/test_appcontainer_sandbox.py b/tests/backend/test_appcontainer_sandbox.py
index 70e92fae..ede7f931 100644
--- a/tests/backend/test_appcontainer_sandbox.py
+++ b/tests/backend/test_appcontainer_sandbox.py
@@ -4,7 +4,7 @@
from __future__ import annotations
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
import pytest
@@ -173,6 +173,48 @@ def test_launcher_success_path(monkeypatch, tmp_path):
assert code == 0
+def test_main_skips_appcontainer_wrap_for_self_check(monkeypatch, tmp_path):
+ """One-shot --self-check must not CreateProcess into an AppContainer."""
+ from meshchatx import meshchat as meshchat_mod
+ from meshchatx.src.backend.self_check import SELF_CHECK_LABELS
+
+ called = {"launcher": False}
+
+ def boom_launcher(_argv):
+ called["launcher"] = True
+ return 2
+
+ monkeypatch.setattr(meshchat_mod.sys, "platform", "win32")
+ monkeypatch.setattr(meshchat_mod, "appcontainer_requested", lambda: True)
+ monkeypatch.setattr(meshchat_mod, "is_appcontainer_child", lambda: False)
+ monkeypatch.setattr(
+ "meshchatx.src.backend.appcontainer_launcher.run_launcher",
+ boom_launcher,
+ )
+ monkeypatch.setattr(
+ meshchat_mod.sys,
+ "argv",
+ ["meshchat.py", "--storage-dir", str(tmp_path), "--self-check", "--headless"],
+ )
+
+ mock_results = {key: {"status": "ok", "reason": ""} for key in SELF_CHECK_LABELS}
+
+ with (
+ patch("meshchatx.meshchat.ReticulumMeshChat") as mock_app_class,
+ patch("meshchatx.src.backend.identity_context.Database"),
+ patch("meshchatx.src.backend.identity_context.ConfigManager"),
+ patch("aiohttp.web.run_app"),
+ patch.object(meshchat_mod, "_maybe_run_embedded_module", return_value=False),
+ ):
+ mock_app_instance = mock_app_class.return_value
+ mock_app_instance.run_self_test = MagicMock(return_value=mock_results)
+ with pytest.raises(SystemExit) as excinfo:
+ meshchat_mod.main()
+ assert excinfo.value.code == 0
+ assert called["launcher"] is False
+ mock_app_instance.run_self_test.assert_called_once()
+
+
def test_launcher_reports_launch_failure(monkeypatch, tmp_path):
monkeypatch.setattr(launcher.sys, "platform", "win32")
monkeypatch.setattr(

diff --git a/tests/backend/test_shell_service_worker_headers.py b/tests/backend/test_shell_service_worker_headers.py
new file mode 100644
index 00000000..9bb70df6
--- /dev/null
+++ b/tests/backend/test_shell_service_worker_headers.py
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Shell routes: service worker Cache-Control for update discovery."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+SHELL_PY = ROOT / "meshchatx" / "src" / "backend" / "http" / "routes" / "shell.py"
+
+
+def test_service_worker_route_sets_revalidate_cache_control() -> None:
+ source = SHELL_PY.read_text(encoding="utf-8")
+ assert 'path=app.get_public_path("service-worker.js")' in source
+ assert '"Cache-Control": "no-cache, max-age=0, must-revalidate"' in source
+
+
+def test_index_html_remains_no_store() -> None:
+ source = SHELL_PY.read_text(encoding="utf-8")
+ assert '"Cache-Control": "no-cache, no-store"' in source

diff --git a/tests/frontend/BootLoadSmoothness.test.js b/tests/frontend/BootLoadSmoothness.test.js
index 0aabc06b..dd59087a 100644
--- a/tests/frontend/BootLoadSmoothness.test.js
+++ b/tests/frontend/BootLoadSmoothness.test.js
@@ -49,6 +49,9 @@ describe("boot and load smoothness", () => {
expect(main).toContain("requestAnimationFrame");
expect(main).toContain("preloadCriticalRouteChunks");
expect(main).toContain('import("./components/messages/MessagesPage.vue")');
+ expect(main).toContain("ElectronUtils.isElectron()");
+ expect(main).toContain("serviceWorkerRegisterOptions");
+ expect(main).toContain("decideControllerChangeReload");
});
it("App.vue fades non-keepAlive route swaps on canvas background", () => {

diff --git a/tests/frontend/helpers/memoryCacheStorage.js b/tests/frontend/helpers/memoryCacheStorage.js
new file mode 100644
index 00000000..19eee586
--- /dev/null
+++ b/tests/frontend/helpers/memoryCacheStorage.js
@@ -0,0 +1,98 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * In-memory Cache Storage double for shell runtime tests.
+ */
+
+function requestKey(request) {
+ if (typeof request === "string") {
+ return request;
+ }
+ if (request && typeof request.url === "string") {
+ return request.url;
+ }
+ return String(request);
+}
+
+export class MemoryCache {
+ constructor() {
+ this.map = new Map();
+ }
+
+ async match(request) {
+ const key = requestKey(request);
+ const hit = this.map.get(key);
+ return hit ? hit.clone() : undefined;
+ }
+
+ async put(request, response) {
+ this.map.set(requestKey(request), response.clone());
+ }
+
+ async keys() {
+ return [...this.map.keys()].map((url) => ({ url }));
+ }
+
+ urls() {
+ return [...this.map.keys()];
+ }
+}
+
+export class MemoryCacheStorage {
+ constructor() {
+ this.stores = new Map();
+ }
+
+ async open(name) {
+ if (!this.stores.has(name)) {
+ this.stores.set(name, new MemoryCache());
+ }
+ return this.stores.get(name);
+ }
+
+ async keys() {
+ return [...this.stores.keys()];
+ }
+
+ async delete(name) {
+ return this.stores.delete(name);
+ }
+
+ async match(request) {
+ for (const cache of this.stores.values()) {
+ const hit = await cache.match(request);
+ if (hit) {
+ return hit;
+ }
+ }
+ return undefined;
+ }
+
+ allUrls() {
+ const urls = [];
+ for (const cache of this.stores.values()) {
+ urls.push(...cache.urls());
+ }
+ return urls;
+ }
+}
+
+export function okResponse(body, init = {}) {
+ return new Response(body, {
+ status: 200,
+ statusText: "OK",
+ headers: { "content-type": "text/plain", ...(init.headers || {}) },
+ ...init,
+ });
+}
+
+export function makeRequest(url, init = {}) {
+ const headers = new Headers(init.headers || {});
+ return {
+ url,
+ method: init.method || "GET",
+ mode: init.mode || "cors",
+ destination: init.destination || "",
+ headers,
+ };
+}

diff --git a/tests/frontend/serviceWorkerBuild.test.js b/tests/frontend/serviceWorkerBuild.test.js
new file mode 100644
index 00000000..97357af2
--- /dev/null
+++ b/tests/frontend/serviceWorkerBuild.test.js
@@ -0,0 +1,74 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import {
+ DEFAULT_SHELL_PRECACHE,
+ renderServiceWorkerSource,
+ stripEsmForServiceWorker,
+ writeServiceWorker,
+} from "../../scripts/build/generate_service_worker.mjs";
+
+const ROOT = resolve(import.meta.dirname, "../..");
+
+describe("service worker build", () => {
+ it("template bootstrap wires runtime prune, claim, and fetch resolve", () => {
+ const template = readFileSync(resolve(ROOT, "meshchatx/src/frontend/sw/service-worker.template.js"), "utf8");
+ expect(template).toContain("createShellRuntime");
+ expect(template).toContain("pruneOldShellCaches");
+ expect(template).toContain("skipWaiting");
+ expect(template).toContain("clients.claim");
+ expect(template).toContain("navigationPreload");
+ expect(template).toContain("UPDATE_MESSAGE_TYPE");
+ expect(template).toContain("resolveFetch");
+ expect(template).toContain("__MESHCHATX_SW_BUILD_ID__");
+ expect(template).toContain("__MESHCHATX_SW_PRECACHE_JSON__");
+ });
+
+ it("stripEsmForServiceWorker removes imports and exports", () => {
+ const stripped = stripEsmForServiceWorker(
+ 'import { x } from "./y.js";\nexport function foo() { return 1; }\nexport const BAR = 2;\n'
+ );
+ expect(stripped).not.toContain("import ");
+ expect(stripped).not.toContain("export ");
+ expect(stripped).toContain("function foo()");
+ expect(stripped).toContain("const BAR = 2");
+ });
+
+ it("renderServiceWorkerSource inlines policy+runtime and injects build id", () => {
+ const source = renderServiceWorkerSource({
+ buildId: "test-build",
+ precacheUrls: DEFAULT_SHELL_PRECACHE,
+ });
+ expect(source).toContain('const BUILD_ID = "test-build"');
+ expect(source).toContain('"/boot-theme.js"');
+ expect(source).toContain("function classifyShellRequest");
+ expect(source).toContain("function createShellRuntime");
+ expect(source).toContain("function findForbiddenCachedUrls");
+ expect(source).not.toContain("__MESHCHATX_SW_BUILD_ID__");
+ expect(source).not.toContain("__MESHCHATX_SW_PRECACHE_JSON__");
+ expect(source).not.toMatch(/^import /m);
+ expect(source).toContain("pruneOldShellCaches");
+ });
+
+ it("public service-worker.js is generated for dev without placeholders", () => {
+ const result = writeServiceWorker({ buildId: "dev" });
+ const publicSw = readFileSync(result.outfile, "utf8");
+ expect(publicSw).toContain('const BUILD_ID = "dev"');
+ expect(publicSw).toContain("createShellRuntime");
+ expect(publicSw).toContain("resolveFetch");
+ expect(publicSw).not.toContain("__MESHCHATX_SW_");
+ });
+
+ it("main.js uses client register helpers and skips Electron", () => {
+ const main = readFileSync(resolve(ROOT, "meshchatx/src/frontend/main.js"), "utf8");
+ expect(main).toContain("ElectronUtils.isElectron()");
+ expect(main).toContain("decideControllerChangeReload");
+ expect(main).toContain("serviceWorkerRegisterOptions");
+ expect(main).toContain("isIgnorableServiceWorkerRegistrationError");
+ expect(main).toContain("controllerchange");
+ expect(main).toContain("registration.update()");
+ expect(main).toContain("visibilitychange");
+ });
+});

diff --git a/tests/frontend/swCachePolicy.adversarial.test.js b/tests/frontend/swCachePolicy.adversarial.test.js
new file mode 100644
index 00000000..88da3004
--- /dev/null
+++ b/tests/frontend/swCachePolicy.adversarial.test.js
@@ -0,0 +1,144 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Adversarial fuzzing and independent oracles for shell cache policy.
+ */
+
+import { describe, expect, it } from "vitest";
+import {
+ cacheNameForBuild,
+ classifyShellRequest,
+ isApiPath,
+ shouldBypassCache,
+} from "../../meshchatx/src/frontend/js/pwa/swCachePolicy.js";
+import { oracleExpectedStrategy } from "../../meshchatx/src/frontend/js/pwa/swShellRuntime.js";
+
+function mulberry32(seed) {
+ let t = seed >>> 0;
+ return () => {
+ t += 0x6d2b79f5;
+ let r = Math.imul(t ^ (t >>> 15), 1 | t);
+ r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+function req(url, init = {}) {
+ const headers = new Headers(init.headers || {});
+ return {
+ url,
+ method: init.method || "GET",
+ mode: init.mode || "cors",
+ destination: init.destination || "",
+ headers,
+ };
+}
+
+function assertClassifyMatchesOracle(request, url) {
+ const actual = classifyShellRequest(request, url);
+ const expected = oracleExpectedStrategy({
+ method: request.method,
+ pathname: url.pathname,
+ mode: request.mode,
+ destination: request.destination,
+ accept: request.headers.get("accept") || "",
+ });
+ expect(actual).toBe(expected);
+ if (expected === "bypass") {
+ expect(shouldBypassCache(request, url)).toBe(true);
+ }
+ if (expected === "asset") {
+ expect(url.pathname.startsWith("/assets")).toBe(true);
+ expect(isApiPath(url.pathname)).toBe(false);
+ }
+ if (expected === "bypass" && (url.pathname.startsWith("/api") || url.pathname.startsWith("/ws"))) {
+ expect(["bypass"]).toContain(actual);
+ }
+}
+
+describe("swCachePolicy adversarial / oracle", () => {
+ it("oracle: API and WS never classify as cacheable strategies", () => {
+ for (const pathname of ["/api", "/api/v1/status", "/api/v1/auth/status", "/ws", "/ws/telephone/audio"]) {
+ const url = new URL(`https://127.0.0.1${pathname}`);
+ assertClassifyMatchesOracle(req(url.href), url);
+ expect(classifyShellRequest(req(url.href), url)).toBe("bypass");
+ }
+ });
+
+ it("oracle: mutating methods always bypass even for assets", () => {
+ const url = new URL("https://127.0.0.1/assets/app.js");
+ for (const method of ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) {
+ assertClassifyMatchesOracle(req(url.href, { method }), url);
+ expect(classifyShellRequest(req(url.href, { method }), url)).toBe("bypass");
+ }
+ });
+
+ it("oracle: path traversal-looking API prefixes still bypass", () => {
+ for (const pathname of ["/api/../api/v1/status", "/api/%2e%2e/v1/status"]) {
+ const url = new URL(`https://127.0.0.1${pathname}`);
+ // URL parser normalizes ../ so /api/../api/v1/status -> /api/v1/status
+ assertClassifyMatchesOracle(req(url.href), url);
+ }
+ });
+
+ it("oracle: cache names never escape prefix and sanitize hostile build ids", () => {
+ const hostile = ["../escape", "a/b", "x y", "v1;drop", "✨", ""];
+ for (const buildId of hostile) {
+ const name = cacheNameForBuild(buildId);
+ expect(name.startsWith("meshchatx-shell-v")).toBe(true);
+ expect(name).not.toContain("/");
+ expect(name).not.toContain(" ");
+ expect(name).not.toContain(";");
+ }
+ });
+
+ it("fuzz: random path/method/mode inputs match independent oracle", () => {
+ const rand = mulberry32(0xcace);
+ const methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
+ const modes = ["cors", "navigate", "no-cors", "same-origin"];
+ const destinations = ["", "document", "script", "style", "image", "worker"];
+ const paths = [
+ "/",
+ "/index.html",
+ "/assets/x.js",
+ "/assets/vendor/a.css",
+ "/boot-theme.js",
+ "/manifest.json",
+ "/favicons/favicon-512x512.png",
+ "/favicon.ico",
+ "/api/v1/status",
+ "/api/v1/config",
+ "/ws",
+ "/ws/telephone/audio",
+ "/service-worker.js",
+ "/vendor/micron.wasm",
+ "/meshchatx-docs/en/getting-started.md",
+ "/#/messages",
+ "//evil.example/assets/x.js",
+ "/assets",
+ "/api",
+ "/API/v1/status",
+ "/assets/../api/v1/status",
+ ];
+ for (let i = 0; i < 400; i++) {
+ const pathname = paths[Math.floor(rand() * paths.length)];
+ const method = methods[Math.floor(rand() * methods.length)];
+ const mode = modes[Math.floor(rand() * modes.length)];
+ const destination = destinations[Math.floor(rand() * destinations.length)];
+ const accept = rand() > 0.7 ? "text/html" : "application/json";
+ let url;
+ try {
+ url = new URL(pathname, "https://127.0.0.1");
+ } catch {
+ continue;
+ }
+ const request = req(url.href, {
+ method,
+ mode,
+ destination,
+ headers: { accept },
+ });
+ assertClassifyMatchesOracle(request, url);
+ }
+ });
+});

diff --git a/tests/frontend/swCachePolicy.test.js b/tests/frontend/swCachePolicy.test.js
new file mode 100644
index 00000000..26f8756a
--- /dev/null
+++ b/tests/frontend/swCachePolicy.test.js
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect } from "vitest";
+import {
+ SHELL_CACHE_PREFIX,
+ cacheNameForBuild,
+ isApiPath,
+ isHashedAssetPath,
+ isShellHelperPath,
+ isNavigationRequest,
+ shouldBypassCache,
+ classifyShellRequest,
+} from "../../meshchatx/src/frontend/js/pwa/swCachePolicy.js";
+
+function req(url, init = {}) {
+ const headers = new Headers(init.headers || {});
+ return {
+ url,
+ method: init.method || "GET",
+ mode: init.mode || "cors",
+ destination: init.destination || "",
+ headers,
+ };
+}
+
+describe("swCachePolicy", () => {
+ it("builds versioned cache names", () => {
+ expect(cacheNameForBuild("2026-07-24T12:00:00.000Z")).toBe(`${SHELL_CACHE_PREFIX}v2026-07-24T12_00_00.000Z`);
+ expect(cacheNameForBuild("")).toBe(`${SHELL_CACHE_PREFIX}vdev`);
+ });
+
+ it("detects API and asset paths", () => {
+ expect(isApiPath("/api")).toBe(true);
+ expect(isApiPath("/api/v1/status")).toBe(true);
+ expect(isApiPath("/assets/index.js")).toBe(false);
+ expect(isHashedAssetPath("/assets/app-abc.js")).toBe(true);
+ expect(isHashedAssetPath("/assets")).toBe(true);
+ expect(isHashedAssetPath("/boot-theme.js")).toBe(false);
+ });
+
+ it("detects shell helper paths", () => {
+ expect(isShellHelperPath("/boot-theme.js")).toBe(true);
+ expect(isShellHelperPath("/manifest.json")).toBe(true);
+ expect(isShellHelperPath("/favicons/favicon-512x512.png")).toBe(true);
+ expect(isShellHelperPath("/api/v1/status")).toBe(false);
+ });
+
+ it("detects navigation requests", () => {
+ expect(isNavigationRequest(req("https://127.0.0.1/", { mode: "navigate" }))).toBe(true);
+ expect(isNavigationRequest(req("https://127.0.0.1/", { destination: "document" }))).toBe(true);
+ expect(
+ isNavigationRequest(
+ req("https://127.0.0.1/", {
+ headers: { accept: "text/html,application/xhtml+xml" },
+ })
+ )
+ ).toBe(true);
+ expect(isNavigationRequest(req("https://127.0.0.1/assets/x.js"))).toBe(false);
+ });
+
+ it("bypasses non-GET, API, ws, and service worker script", () => {
+ const apiUrl = new URL("https://127.0.0.1/api/v1/status");
+ expect(shouldBypassCache(req(apiUrl.href, { method: "POST" }), apiUrl)).toBe(true);
+ expect(shouldBypassCache(req(apiUrl.href), apiUrl)).toBe(true);
+ const wsUrl = new URL("https://127.0.0.1/ws");
+ expect(shouldBypassCache(req(wsUrl.href), wsUrl)).toBe(true);
+ const swUrl = new URL("https://127.0.0.1/service-worker.js");
+ expect(shouldBypassCache(req(swUrl.href), swUrl)).toBe(true);
+ const assetUrl = new URL("https://127.0.0.1/assets/a.js");
+ expect(shouldBypassCache(req(assetUrl.href), assetUrl)).toBe(false);
+ });
+
+ it("classifies requests for shell strategies", () => {
+ const assetUrl = new URL("https://127.0.0.1/assets/chunk.js");
+ expect(classifyShellRequest(req(assetUrl.href), assetUrl)).toBe("asset");
+
+ const navUrl = new URL("https://127.0.0.1/");
+ expect(classifyShellRequest(req(navUrl.href, { mode: "navigate" }), navUrl)).toBe("navigation");
+
+ const helperUrl = new URL("https://127.0.0.1/boot-theme.js");
+ expect(classifyShellRequest(req(helperUrl.href), helperUrl)).toBe("shell-helper");
+
+ const apiUrl = new URL("https://127.0.0.1/api/v1/config");
+ expect(classifyShellRequest(req(apiUrl.href), apiUrl)).toBe("bypass");
+
+ const otherUrl = new URL("https://127.0.0.1/vendor/x.wasm");
+ expect(classifyShellRequest(req(otherUrl.href), otherUrl)).toBe("network-only");
+ });
+});

diff --git a/tests/frontend/swShellRuntime.oracle.test.js b/tests/frontend/swShellRuntime.oracle.test.js
new file mode 100644
index 00000000..cca14c02
--- /dev/null
+++ b/tests/frontend/swShellRuntime.oracle.test.js
@@ -0,0 +1,240 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Runtime strategy oracles: races, leaks, update prune, navigation timeout fallback.
+ */
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cacheNameForBuild, SHELL_CACHE_PREFIX } from "../../meshchatx/src/frontend/js/pwa/swCachePolicy.js";
+import {
+ createShellRuntime,
+ findForbiddenCachedUrls,
+ oracleExpectedStrategy,
+ selectCachesToDelete,
+ SHELL_FALLBACK_URL,
+} from "../../meshchatx/src/frontend/js/pwa/swShellRuntime.js";
+import {
+ decideControllerChangeReload,
+ isIgnorableServiceWorkerRegistrationError,
+ serviceWorkerRegisterOptions,
+} from "../../meshchatx/src/frontend/js/pwa/swClientRegister.js";
+import { makeRequest, MemoryCacheStorage, okResponse } from "./helpers/memoryCacheStorage.js";
+
+const ORIGIN = "https://127.0.0.1:8000";
+
+function assertNoApiLeakOracle(cacheStorage) {
+ const leaked = findForbiddenCachedUrls(cacheStorage.allUrls());
+ expect(leaked).toEqual([]);
+}
+
+describe("swShellRuntime oracle / race / leak", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("oracle: cache-first serves cached asset and never stores API", async () => {
+ const caches = new MemoryCacheStorage();
+ const cacheName = cacheNameForBuild("t1");
+ const assetUrl = `${ORIGIN}/assets/app-abc.js`;
+ let fetches = 0;
+ const fetchFn = vi.fn(async (request) => {
+ fetches += 1;
+ const url = typeof request === "string" ? request : request.url;
+ if (url.includes("/api/")) {
+ return okResponse('{"secret":true}', { headers: { "content-type": "application/json" } });
+ }
+ return okResponse("asset-v1");
+ });
+ const runtime = createShellRuntime({
+ caches,
+ fetch: fetchFn,
+ cacheName,
+ origin: ORIGIN,
+ });
+
+ const assetReq = makeRequest(assetUrl);
+ const first = await runtime.cacheFirst(assetReq);
+ expect(await first.text()).toBe("asset-v1");
+ const second = await runtime.cacheFirst(assetReq);
+ expect(await second.text()).toBe("asset-v1");
+ expect(fetches).toBe(1);
+
+ const apiReq = makeRequest(`${ORIGIN}/api/v1/status`);
+ expect(runtime.resolveFetch(apiReq)).toBeNull();
+ expect(oracleExpectedStrategy({ pathname: "/api/v1/status", method: "GET" })).toBe("bypass");
+ assertNoApiLeakOracle(caches);
+ });
+
+ it("oracle: failed network responses are not written to cache", async () => {
+ const caches = new MemoryCacheStorage();
+ const cacheName = cacheNameForBuild("t2");
+ const assetUrl = `${ORIGIN}/assets/missing.js`;
+ const fetchFn = vi.fn(async () => new Response("nope", { status: 404 }));
+ const runtime = createShellRuntime({ caches, fetch: fetchFn, cacheName, origin: ORIGIN });
+ const response = await runtime.cacheFirst(makeRequest(assetUrl));
+ expect(response.status).toBe(404);
+ expect(caches.allUrls()).toEqual([]);
+ });
+
+ it("race: concurrent cache-first misses do not leak and stabilize on one body", async () => {
+ const caches = new MemoryCacheStorage();
+ const cacheName = cacheNameForBuild("race");
+ const assetUrl = `${ORIGIN}/assets/race.js`;
+ let started = 0;
+ let release;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const fetchFn = vi.fn(async () => {
+ started += 1;
+ await gate;
+ return okResponse(`body-${started}`);
+ });
+ const runtime = createShellRuntime({ caches, fetch: fetchFn, cacheName, origin: ORIGIN });
+ const req = makeRequest(assetUrl);
+ const p1 = runtime.cacheFirst(req);
+ const p2 = runtime.cacheFirst(req);
+ await vi.waitFor(() => {
+ expect(started).toBe(2);
+ });
+ release();
+ const [r1, r2] = await Promise.all([p1, p2]);
+ const t1 = await r1.text();
+ const t2 = await r2.text();
+ expect(t1.startsWith("body-")).toBe(true);
+ expect(t2.startsWith("body-")).toBe(true);
+ assertNoApiLeakOracle(caches);
+ const cached = await caches.match(req);
+ expect(cached).toBeTruthy();
+ expect((await cached.text()).startsWith("body-")).toBe(true);
+ });
+
+ it("race: prune deletes only old shell caches and keeps current", async () => {
+ const caches = new MemoryCacheStorage();
+ const oldName = cacheNameForBuild("old");
+ const newName = cacheNameForBuild("new");
+ const oldCache = await caches.open(oldName);
+ const newCache = await caches.open(newName);
+ await oldCache.put(`${ORIGIN}/assets/old.js`, okResponse("old"));
+ await newCache.put(`${ORIGIN}/assets/new.js`, okResponse("new"));
+ await caches.open("unrelated-other");
+
+ const doomed = selectCachesToDelete(await caches.keys(), SHELL_CACHE_PREFIX, newName);
+ expect(doomed).toEqual([oldName]);
+
+ const runtime = createShellRuntime({
+ caches,
+ fetch: vi.fn(),
+ cacheName: newName,
+ origin: ORIGIN,
+ });
+ const deleted = await runtime.pruneOldShellCaches();
+ expect(deleted).toEqual([oldName]);
+ expect(await caches.keys()).toEqual(expect.arrayContaining([newName, "unrelated-other"]));
+ expect(await caches.keys()).not.toContain(oldName);
+ expect(await (await caches.open(newName)).match(`${ORIGIN}/assets/new.js`)).toBeTruthy();
+ });
+
+ it("edge: navigation timeout falls back to cached shell without caching API", async () => {
+ vi.useFakeTimers();
+ const caches = new MemoryCacheStorage();
+ const cacheName = cacheNameForBuild("nav");
+ const shell = okResponse("<html>shell</html>", { headers: { "content-type": "text/html" } });
+ await (await caches.open(cacheName)).put(SHELL_FALLBACK_URL, shell);
+
+ const fetchFn = vi.fn((_request, init = {}) => {
+ return new Promise((resolve, reject) => {
+ if (init.signal) {
+ init.signal.addEventListener("abort", () => {
+ reject(new DOMException("Aborted", "AbortError"));
+ });
+ }
+ });
+ });
+ const runtime = createShellRuntime({
+ caches,
+ fetch: fetchFn,
+ cacheName,
+ origin: ORIGIN,
+ navTimeoutMs: 50,
+ });
+ const navReq = makeRequest(`${ORIGIN}/`, { mode: "navigate" });
+ const pending = runtime.networkFirstNavigation({ request: navReq });
+ await vi.advanceTimersByTimeAsync(60);
+ const response = await pending;
+ expect(await response.text()).toContain("shell");
+ assertNoApiLeakOracle(caches);
+ });
+
+ it("edge: cross-origin and network-only paths bypass respondWith", () => {
+ const caches = new MemoryCacheStorage();
+ const runtime = createShellRuntime({
+ caches,
+ fetch: vi.fn(),
+ cacheName: cacheNameForBuild("x"),
+ origin: ORIGIN,
+ });
+ expect(runtime.resolveFetch(makeRequest("https://evil.example/assets/x.js"))).toBeNull();
+ expect(runtime.resolveFetch(makeRequest(`${ORIGIN}/vendor/x.wasm`))).toBeNull();
+ expect(runtime.resolveFetch(makeRequest(`${ORIGIN}/api/v1/status`))).toBeNull();
+ expect(runtime.resolveFetch(makeRequest(`${ORIGIN}/ws`))).toBeNull();
+ });
+
+ it("edge: stale-while-revalidate returns cache immediately then refreshes", async () => {
+ const caches = new MemoryCacheStorage();
+ const cacheName = cacheNameForBuild("swr");
+ const url = `${ORIGIN}/boot-theme.js`;
+ await (await caches.open(cacheName)).put(url, okResponse("old-theme"));
+ let resolveNet;
+ const net = new Promise((resolve) => {
+ resolveNet = resolve;
+ });
+ const fetchFn = vi.fn(() => net);
+ const runtime = createShellRuntime({ caches, fetch: fetchFn, cacheName, origin: ORIGIN });
+ const first = await runtime.staleWhileRevalidate(makeRequest(url));
+ expect(await first.text()).toBe("old-theme");
+ resolveNet(okResponse("new-theme"));
+ await vi.waitFor(async () => {
+ const cached = await (await caches.open(cacheName)).match(url);
+ expect(await cached.text()).toBe("new-theme");
+ });
+ });
+
+ it("leak oracle: findForbiddenCachedUrls catches api and ws entries", () => {
+ expect(
+ findForbiddenCachedUrls([
+ `${ORIGIN}/assets/a.js`,
+ `${ORIGIN}/api/v1/status`,
+ `${ORIGIN}/ws`,
+ "/boot-theme.js",
+ ])
+ ).toEqual(["/api/v1/status", "/ws"]);
+ });
+});
+
+describe("swClientRegister update lifecycle oracle", () => {
+ it("oracle: first install does not reload, update reloads once", () => {
+ expect(decideControllerChangeReload({ hadController: false, refreshing: false })).toEqual({
+ shouldReload: false,
+ nextRefreshing: false,
+ });
+ const first = decideControllerChangeReload({ hadController: true, refreshing: false });
+ expect(first).toEqual({ shouldReload: true, nextRefreshing: true });
+ expect(decideControllerChangeReload({ hadController: true, refreshing: true })).toEqual({
+ shouldReload: false,
+ nextRefreshing: true,
+ });
+ });
+
+ it("oracle: certificate errors are ignorable, others are not", () => {
+ expect(isIgnorableServiceWorkerRegistrationError({ name: "SecurityError", message: "x" })).toBe(true);
+ expect(
+ isIgnorableServiceWorkerRegistrationError({ name: "TypeError", message: "SSL certificate problem" })
+ ).toBe(true);
+ expect(isIgnorableServiceWorkerRegistrationError({ name: "TypeError", message: "network down" })).toBe(false);
+ });
+
+ it("oracle: register options force updateViaCache none", () => {
+ expect(serviceWorkerRegisterOptions()).toEqual({ updateViaCache: "none" });
+ });
+});

diff --git a/vite.config.js b/vite.config.js
index fec2405c..4b5bf0ef 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -2,6 +2,7 @@ import path from "path";
import fs from "fs";
import { defineConfig } from "vite";
import { MICRON_PARSER_GO_RELEASE_TAG } from "./scripts/micron-parser-go-version.mjs";
+import { meshchatxServiceWorkerPlugin } from "./scripts/build/generate_service_worker.mjs";
import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue";
import vuetify from "vite-plugin-vuetify";
@@ -173,6 +174,7 @@ export default defineConfig({
},
}),
vuetify(),
+ meshchatxServiceWorkerPlugin({ buildId: appBuildTimeIso }),
],
server: {


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────